AWS CDKでECS Fargate Bastionを一撃で作ってみた
EC2インスタンスの踏み台を用意したくない
こんにちは、のんピ(@non____97)です。
皆さんはEC2インスタンスの踏み台を用意したくないと思ったことはありますか? 私はあります。
VPC上のRDS DBインスタンスやRedisクラスター、OpenSearch Service ドメインなどのリソースに接続したい場合、Site-to-Site VPNやClient VPN、Direct Connectがなければ踏み台(Bastion)が必要になります。
踏み台へのアクセス方法は以下のようなものがあります。
- 直接SSH
- SSMセッションマネージャー
- EC2 Instance Connect
そして、踏み台となるリソースとして採用される多くがEC2インスタンスだと考えます。EC2インスタンスの場合、OS周りの面倒をみる必要があります。OS内のパッケージのアップデートが面倒であれば「踏み台が欲しいタイミングにEC2インスタンスを一瞬だけ作って、不要になったら削除する」という運用も考えられますが、起動に時間がかかりそうです。また、ワクワクしません。
そんな時に活躍するのがECS Fargateを使った踏み台です。FargateなのでOSの面倒をみる必要がなくなります。コンテナなので起動もEC2インスタンスと比較して早いでしょう。そんなECS FargateのBastionは偉大な先人達が既に紹介しています。
私もAWS CDKを使って一撃でECS Fargate Bastionを作ってみたくなったのでチャレンジしてみました。ロマンを追い求めています。
使用するコードの構成
使用したコードは以下リポジトリに保存しています。
ディレクトリツリーは以下のとおりです。
> tree
.
├── .gitignore
├── .npmignore
├── README.md
├── bin
│ └── ecs-fargate-bastion.ts
├── cdk.context.json
├── cdk.json
├── jest.config.js
├── lib
│ ├── construct
│ │ ├── ecs-fargate-construct.ts
│ │ └── vpc-endpoint-construct.ts
│ ├── ecs-fargate-bastion-stack.ts
│ └── parameter
│ └── index.ts
├── package-lock.json
├── package.json
├── test
│ └── ecs-fargate-bastion.test.ts
└── tsconfig.json
6 directories, 15 files
「デプロイ先のVPCにはNAT Gatewayはない。ECSで使用するVPCエンドポイントもない」という環境もあるでしょう。ということで必要に応じてVPCエンドポイントも作成するような構成にしています。具体的なStackのコードは以下のとおりです。
import * as cdk from "aws-cdk-lib";
import { Construct } from "constructs";
import { VpcEndpointParams, EcsFargateParams } from "./parameter";
import { VpcEndpointConstruct } from "./construct/vpc-endpoint-construct";
import { EcsFargateConstruct } from "./construct/ecs-fargate-construct";
export interface EcsFargateBastionStackProps extends cdk.StackProps {
vpcId: string;
vpcEndpointParams?: VpcEndpointParams;
ecsFargateParams: EcsFargateParams;
}
export class EcsFargateBastionStack extends cdk.Stack {
constructor(
scope: Construct,
id: string,
props: EcsFargateBastionStackProps
) {
super(scope, id, props);
// VPC Endpoints
const vpcEndpointConstruct = props.vpcEndpointParams
? new VpcEndpointConstruct(this, "VpcEndpoint", {
vpcId: props.vpcId,
vpcEndpointParams: props.vpcEndpointParams,
})
: undefined;
// ECS Fargate
const ecsFargateConstruct = new EcsFargateConstruct(this, "EcsFargate", {
vpcId: props.vpcId,
ecsFargateParams: props.ecsFargateParams,
});
if (vpcEndpointConstruct) {
ecsFargateConstruct.node.addDependency(vpcEndpointConstruct);
}
}
}
「ECSで使用するVPCエンドポイントはあるけど、SSMセッションマネージャーで使用するVPCエンドポイントはない」といった場合もあると思います。後述する./lib/parameter/index.ts
で指定したフラグに応じて、以下のVPCエンドポイントを指定されたサブネット上に作成するようにしています。
shouldCreateEcrVpcEndpoint
が truecom.amazonaws.region.ecr.dkr
com.amazonaws.region.ecr.api
shouldCreateSsmVpcEndpoint
が truecom.amazonaws.region.ssm
com.amazonaws.region.ssmmessages
shouldCreateLogsVpcEndpoint
が truecom.amazonaws.region.ssm
shouldCreateS3VpcEndpoint
が truecom.amazonaws.region.s3
(Gateway型)
ECSで使用するVPCエンドポイントは以下記事にまとまっています。
具体的なVPCエンドポイントのConstructのコードは以下のとおりです。
import * as cdk from "aws-cdk-lib";
import { Construct } from "constructs";
import { VpcEndpointParams } from "../parameter";
export interface VpcEndpointConstructProps {
vpcId: string;
vpcEndpointParams: VpcEndpointParams;
}
export class VpcEndpointConstruct extends Construct {
public readonly ecrRepository: cdk.aws_ecr.IRepository;
constructor(scope: Construct, id: string, props: VpcEndpointConstructProps) {
super(scope, id);
// VPC
const vpc = cdk.aws_ec2.Vpc.fromLookup(this, "Vpc", {
vpcId: props.vpcId,
});
if (props.vpcEndpointParams.shouldCreateEcrVpcEndpoint) {
// ECR
vpc.addInterfaceEndpoint("EcrEndpoint", {
service: cdk.aws_ec2.InterfaceVpcEndpointAwsService.ECR,
subnets: vpc.selectSubnets(
props.vpcEndpointParams.vpcEndpointSubnetSelection
),
});
// ECR DOCKER
vpc.addInterfaceEndpoint("EcrDockerEndpoint", {
service: cdk.aws_ec2.InterfaceVpcEndpointAwsService.ECR_DOCKER,
subnets: vpc.selectSubnets(
props.vpcEndpointParams.vpcEndpointSubnetSelection
),
});
}
if (props.vpcEndpointParams.shouldCreateSsmVpcEndpoint) {
// SSM
vpc.addInterfaceEndpoint("SsmEndpoint", {
service: cdk.aws_ec2.InterfaceVpcEndpointAwsService.SSM,
subnets: vpc.selectSubnets(
props.vpcEndpointParams.vpcEndpointSubnetSelection
),
});
// SSM MESSAGES
vpc.addInterfaceEndpoint("SsmMessagesEndpoint", {
service: cdk.aws_ec2.InterfaceVpcEndpointAwsService.SSM_MESSAGES,
subnets: vpc.selectSubnets(
props.vpcEndpointParams.vpcEndpointSubnetSelection
),
});
}
if (props.vpcEndpointParams.shouldCreateLogsVpcEndpoint) {
// LOGS
vpc.addInterfaceEndpoint("LogsEndpoint", {
service: cdk.aws_ec2.InterfaceVpcEndpointAwsService.CLOUDWATCH_LOGS,
subnets: vpc.selectSubnets(
props.vpcEndpointParams.vpcEndpointSubnetSelection
),
});
}
if (props.vpcEndpointParams.shouldCreateS3VpcEndpoint) {
// Gateway S3
vpc.addGatewayEndpoint(`S3GatewayEndpoint`, {
service: cdk.aws_ec2.GatewayVpcEndpointAwsService.S3,
});
}
}
}
ECS FargateのConstructは以下のとおりです。./lib/parameter/index.ts
で指定されたコンテナイメージを使って起動するというものです。「Pull through cacheを使いたいな」という方向けにも対応しています。
import * as cdk from "aws-cdk-lib";
import { Construct } from "constructs";
import { EcsFargateParams } from "../parameter";
export interface EcsFargateConstructProps {
vpcId: string;
ecsFargateParams: EcsFargateParams;
}
export class EcsFargateConstruct extends Construct {
constructor(scope: Construct, id: string, props: EcsFargateConstructProps) {
super(scope, id);
// Pull through cache rules
const pullThroughCacheRule = props.ecsFargateParams.ecrRepositoryPrefix
? new cdk.aws_ecr.CfnPullThroughCacheRule(this, "PullThroughCacheRule", {
ecrRepositoryPrefix: props.ecsFargateParams.ecrRepositoryPrefix,
upstreamRegistryUrl: "public.ecr.aws",
})
: undefined;
// VPC
const vpc = cdk.aws_ec2.Vpc.fromLookup(this, "Vpc", {
vpcId: props.vpcId,
});
// Log Group
const logGroup = new cdk.aws_logs.LogGroup(this, "LogGroup", {
logGroupName: `/ecs/${props.ecsFargateParams.clusterName}/${props.ecsFargateParams.repositoryName}/ecs-exec`,
removalPolicy: cdk.RemovalPolicy.DESTROY,
retention: cdk.aws_logs.RetentionDays.TWO_WEEKS,
});
// ECS Cluster
const cluster = new cdk.aws_ecs.Cluster(this, "Cluster", {
vpc,
containerInsights: false,
clusterName: props.ecsFargateParams.clusterName,
executeCommandConfiguration: {
logging: cdk.aws_ecs.ExecuteCommandLogging.OVERRIDE,
logConfiguration: {
cloudWatchLogGroup: logGroup,
},
},
});
// Task definition
const taskDefinition = new cdk.aws_ecs.FargateTaskDefinition(
this,
"TaskDefinition",
{
cpu: 256,
memoryLimitMiB: 512,
runtimePlatform: {
cpuArchitecture: cdk.aws_ecs.CpuArchitecture.ARM64,
operatingSystemFamily: cdk.aws_ecs.OperatingSystemFamily.LINUX,
},
}
);
// Container
taskDefinition.addContainer("Container", {
image: pullThroughCacheRule?.ecrRepositoryPrefix
? cdk.aws_ecs.ContainerImage.fromEcrRepository(
cdk.aws_ecr.Repository.fromRepositoryName(
this,
pullThroughCacheRule.ecrRepositoryPrefix,
props.ecsFargateParams.repositoryName
),
props.ecsFargateParams.imagesTag
)
: cdk.aws_ecs.ContainerImage.fromRegistry(
`${props.ecsFargateParams.repositoryName}:${props.ecsFargateParams.imagesTag}`
),
pseudoTerminal: true,
linuxParameters: new cdk.aws_ecs.LinuxParameters(
this,
"LinuxParameters",
{
initProcessEnabled: true,
}
),
});
// Pull through cache Policy
if (pullThroughCacheRule?.ecrRepositoryPrefix) {
taskDefinition.obtainExecutionRole().attachInlinePolicy(
new cdk.aws_iam.Policy(this, "PullThroughCachePolicy", {
statements: [
new cdk.aws_iam.PolicyStatement({
actions: ["ecr:CreateRepository", "ecr:BatchImportUpstreamImage"],
resources: [
`arn:aws:ecr:${cdk.Stack.of(this).region}:${
cdk.Stack.of(this).account
}:repository/${props.ecsFargateParams.ecrRepositoryPrefix}/*`,
],
}),
],
})
);
}
// Attache Security Group
const securityGroups = props.ecsFargateParams.ecsServiceSecurityGroupIds
? props.ecsFargateParams.ecsServiceSecurityGroupIds.map(
(securityGroupId) => {
return cdk.aws_ec2.SecurityGroup.fromSecurityGroupId(
this,
`EcsServiceSecurityGroupId_${securityGroupId}`,
securityGroupId
);
}
)
: undefined;
// ECS Service
const ecsService = new cdk.aws_ecs.FargateService(this, "Service", {
cluster,
enableExecuteCommand: true,
taskDefinition,
desiredCount: props.ecsFargateParams.desiredCount,
minHealthyPercent: 100,
maxHealthyPercent: 200,
deploymentController: {
type: cdk.aws_ecs.DeploymentControllerType.ECS,
},
circuitBreaker: { rollback: true },
securityGroups,
vpcSubnets: props.ecsFargateParams.ecsFargateSubnetSelection,
});
// Allow dst Security Group from ECS Service Security Group
props.ecsFargateParams.inboundFromEcsServiceAllowedSecurityGroupId?.forEach(
(allowRule) => {
const securityGroup = cdk.aws_ec2.SecurityGroup.fromSecurityGroupId(
this,
`InboundFromEcsServiceAllowedSecurityGroupId_${allowRule.securityGroupId}`,
allowRule.securityGroupId
);
allowRule.ports.forEach((port) => {
ecsService.connections.securityGroups.forEach(
(ecsServiceSecurityGroup) => {
securityGroup.addIngressRule(
ecsServiceSecurityGroup,
port,
`Inbound ${props.ecsFargateParams.clusterName} service`
);
}
);
});
}
);
}
}
どのコンテナイメージを使うのか、どのVPCのどのサブネットにデプロイするかは./lib/parameter/index.ts
で指定します。各プロパティの説明はコード内にコメントしています。
import * as cdk from "aws-cdk-lib";
export interface VpcEndpointParams {
vpcEndpointSubnetSelection?: cdk.aws_ec2.SubnetSelection;
shouldCreateEcrVpcEndpoint?: boolean;
shouldCreateSsmVpcEndpoint?: boolean;
shouldCreateLogsVpcEndpoint?: boolean;
shouldCreateS3VpcEndpoint?: boolean;
}
export interface EcsFargateParams {
ecsFargateSubnetSelection: cdk.aws_ec2.SubnetSelection;
clusterName: string;
ecrRepositoryPrefix?: string;
repositoryName: string;
imagesTag: string;
desiredCount: number;
ecsServiceSecurityGroupIds?: string[];
inboundFromEcsServiceAllowedSecurityGroupId?: {
securityGroupId: string;
ports: cdk.aws_ec2.Port[];
}[];
}
export interface EcsFargateBastionStackParams {
env?: cdk.Environment;
property: {
vpcId: string;
vpcEndpointParams?: VpcEndpointParams;
ecsFargateParams: EcsFargateParams;
};
}
export const ecsFargateBastionStackParams: EcsFargateBastionStackParams = {
env: {
account: process.env.CDK_DEFAULT_ACCOUNT,
region: process.env.CDK_DEFAULT_REGION,
},
property: {
vpcId: "vpc-0c923cc42e5fb2cbf", // デプロイ先のVPCのID
vpcEndpointParams: { // VPCエンドポイントを作成する時に使用するパラメーター
vpcEndpointSubnetSelection: { // VPCエンドポイントをどのサブネットにデプロイするか指定
subnetType: cdk.aws_ec2.SubnetType.PRIVATE_ISOLATED,
availabilityZones: ["us-east-1a"],
},
shouldCreateEcrVpcEndpoint: true, // ECS関連のVPCエンドポイントを作成するか
shouldCreateSsmVpcEndpoint: true, // SSM関連のVPCエンドポイントを作成するか
shouldCreateLogsVpcEndpoint: true, // CloudWatch LogsのVPCエンドポイントを作成するか
shouldCreateS3VpcEndpoint: true, // Gateway型のS3 VPCエンドポイントを作成するか
},
ecsFargateParams: { // ECS Fargateを作成する時に使用するパラメーター
ecsFargateSubnetSelection: { // ECS Fargateをどのサブネットにデプロイするか
subnetType: cdk.aws_ec2.SubnetType.PRIVATE_ISOLATED,
availabilityZones: ["us-east-1a"],
},
clusterName: "ecs-fargate-bastion", // ECSクラスターの名前
ecrRepositoryPrefix: "ecr-public-pull-through", // Pull through cache ruleに指定するリポジトリのプレフィックス
repositoryName: "ecr-public-pull-through/ubuntu/ubuntu", // リポジトリ名
imagesTag: "22.04", // 使用するコンテナイメージのタグ
desiredCount: 1, // デプロイするタスクの数
ecsServiceSecurityGroupIds: [ // ECSサービスのSecurity GroupのID
"sg-0e5bce3c653793012",
"sg-0a15755f2fb642698",
],
inboundFromEcsServiceAllowedSecurityGroupId: [ // ECSサービスからのインバウンド通信を許可するSecurity GroupのID
{
securityGroupId: "sg-0a15755f2fb642698",
ports: [cdk.aws_ec2.Port.allTcp(), cdk.aws_ec2.Port.allIcmp()],
},
],
},
},
};
やってみた
検証環境
検証環境は以下のとおりです。
こちらの環境は以下リポジトリのコードをベースに作成しました。
この環境上に用意したAWS CDKのコードを用いてECS Fargate Bastionをデプロイします。
Egress Subnetにデプロイ
まず、Egress Subnetにデプロイします。./lib/parameter/index.ts
は以下のとおりです。
export const ecsFargateBastionStackParams: EcsFargateBastionStackParams = {
env: {
account: process.env.CDK_DEFAULT_ACCOUNT,
region: process.env.CDK_DEFAULT_REGION,
},
property: {
vpcId: "vpc-0c923cc42e5fb2cbf",
ecsFargateParams: {
ecsFargateSubnetSelection: {
subnetFilters: [
cdk.aws_ec2.SubnetFilter.byIds(["subnet-02ea7423910b506f4"]),
],
},
clusterName: "ecs-fargate-bastion",
repositoryName: "public.ecr.aws/ubuntu/ubuntu",
imagesTag: "22.04",
desiredCount: 1,
ecsServiceSecurityGroupIds: ["sg-05744485862b195ae"],
},
},
};
構成図にすると以下のとおりです。
起動したECSのタスクを確認します。
試しにECS Execでコンテナに接続してみます。
$ cluster_name="ecs-fargate-bastion"
$ task_id=$(
aws ecs list-tasks \
--cluster "${cluster_name}" \
--query 'taskArns[0]' \
--output text \
| sed 's/.*'"${cluster_name}"'\///'
)
$ aws ecs execute-command \
--cluster "${cluster_name}" \
--task "${task_id}" \
--container Container \
--interactive \
--command "/bin/sh"
The Session Manager plugin was installed successfully. Use the AWS CLI to start a session.
Starting session with SessionId: ecs-execute-command-03e53b704c35dfdb5
# ls -l
total 56
lrwxrwxrwx 1 root root 7 Feb 27 16:01 bin -> usr/bin
drwxr-xr-x 2 root root 4096 Apr 18 2022 boot
drwxr-xr-x 5 root root 380 Mar 6 10:12 dev
drwxr-xr-x 1 root root 4096 Mar 6 10:12 etc
drwxr-xr-x 2 root root 4096 Apr 18 2022 home
lrwxrwxrwx 1 root root 7 Feb 27 16:01 lib -> usr/lib
drwxr-xr-x 3 root root 4096 Mar 6 10:12 managed-agents
drwxr-xr-x 2 root root 4096 Feb 27 16:01 media
drwxr-xr-x 2 root root 4096 Feb 27 16:01 mnt
drwxr-xr-x 2 root root 4096 Feb 27 16:01 opt
dr-xr-xr-x 188 root root 0 Mar 6 10:12 proc
drwx------ 2 root root 4096 Feb 27 16:08 root
drwxr-xr-x 5 root root 4096 Feb 27 16:08 run
lrwxrwxrwx 1 root root 8 Feb 27 16:01 sbin -> usr/sbin
drwxr-xr-x 2 root root 4096 Feb 27 16:01 srv
dr-xr-xr-x 12 root root 0 Mar 6 10:12 sys
drwxrwxrwt 2 root root 4096 Feb 27 16:08 tmp
drwxr-xr-x 11 root root 4096 Feb 27 16:01 usr
drwxr-xr-x 1 root root 4096 Feb 27 16:08 var
# df -h
Filesystem Size Used Avail Use% Mounted on
overlay 30G 9.5G 19G 34% /
tmpfs 64M 0 64M 0% /dev
shm 461M 0 461M 0% /dev/shm
tmpfs 461M 0 461M 0% /sys/fs/cgroup
/dev/nvme0n1p1 4.9G 2.0G 2.9G 41% /dev/init
/dev/nvme1n1 30G 9.5G 19G 34% /etc/hosts
tmpfs 461M 0 461M 0% /proc/acpi
tmpfs 461M 0 461M 0% /sys/firmware
tmpfs 461M 0 461M 0% /proc/scsi
# hostname
ip-10-1-16-203.ec2.internal
# hostname -a
# exit
Exiting session with sessionId: ecs-execute-command-03e53b704c35dfdb5.
問題なく操作できましたね。
ECS ExecのログはCloudwatch Logsに出力するようにしています。確認すると、確かにログが出力されていました。
ログを出力するにはECSクラスター側でログの出力設定しているのはもちろん、使用するコンテナイメージにはscript
とcat
がインストールされている必要があります。注意しましょう。
また、コマンドログを Amazon S3 または CloudWatch Logs に正しくアップロードするには、コンテナイメージに scriptと catをインストールする必要があることを知っておくことも重要です。
デバッグ用にAmazon ECS Exec を使用 - Amazon Elastic Container Service
続けて、SSMセッションマネージャーでも接続してみます。
$ runtime_id=$(aws ecs describe-tasks \
--cluster "${cluster_name}" \
--task "${task_id}" \
--query 'tasks[].containers[].runtimeId' \
--output text
)
$ aws ssm start-session \
--target "ecs:${cluster_name}_${task_id}_${runtime_id}"
Starting session with SessionId: botocore-session-1709724330-0d1f19c11d18fd7cc
/bin/bash
cd /home/$(whoami)
# root@ip-10-1-16-203:/# cd /home/$(whoami)
bash: cd: /home/root: No such file or directory
root@ip-10-1-16-203:/# ps aufe
USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND
root 135 0.0 0.0 2304 828 pts/1 Ss 11:29 0:00 sh PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin ECS_CONTAINER_METADATA_URI=http://169.254.170.2/v3/0ba05e109d2246bfa4213045b0b3b0c7-2990360344 ECS_CO
root 136 0.0 0.3 4552 3584 pts/1 S 11:29 0:00 \_ /bin/bash HOSTNAME=ip-10-1-16-203.ec2.internal HOME=/root AWS_CONTAINER_CREDENTIALS_RELATIVE_URI=/v2/credentials/b58dd647-9af2-418e-865a-68482af6eaae AWS_EXECUTION_EN
root 141 0.0 0.1 6828 1652 pts/1 R+ 11:30 0:00 \_ ps aufe AWS_EXECUTION_ENV=AWS_ECS_FARGATE AWS_CONTAINER_CREDENTIALS_RELATIVE_URI=/v2/credentials/b58dd647-9af2-418e-865a-68482af6eaae HOSTNAME=ip-10-1-16-203.ec2.
root 1 0.0 0.0 828 4 pts/0 Ss 10:12 0:00 /dev/init -- /bin/bash PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin ECS_CONTAINER_METADATA_URI=http://169.254.170.2/v3/0ba05e109d2246bfa4213045b0b3b0
root 6 0.0 0.3 4132 3144 pts/0 S+ 10:12 0:00 /bin/bash PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin ECS_CONTAINER_METADATA_URI=http://169.254.170.2/v3/0ba05e109d2246bfa4213045b0b3b0c7-2990360344
root@ip-10-1-16-203:/# printenv
AWS_EXECUTION_ENV=AWS_ECS_FARGATE
AWS_CONTAINER_CREDENTIALS_RELATIVE_URI=/v2/credentials/b58dd647-9af2-418e-865a-68482af6eaae
HOSTNAME=ip-10-1-16-203.ec2.internal
AWS_DEFAULT_REGION=us-east-1
AWS_REGION=us-east-1
PWD=/
ECS_CONTAINER_METADATA_URI_V4=http://169.254.170.2/v4/0ba05e109d2246bfa4213045b0b3b0c7-2990360344
HOME=/root
LANG=C.UTF-8
LS_COLORS=rs=0:di=01;34:ln=01;36:mh=00:pi=40;33:so=01;35:do=01;35:bd=40;33;01:cd=40;33;01:or=40;31;01:mi=00:su=37;41:sg=30;43:ca=30;41:tw=30;42:ow=34;42:st=37;44:ex=01;32:*.tar=01;31:*.tgz=01;31:*.arc=01;31:*.arj=01;31:*.taz=01;31:*.lha=01;31:*.lz4=01;31:*.lzh=01;31:*.lzma=01;31:*.tlz=01;31:*.txz=01;31:*.tzo=01;31:*.t7z=01;31:*.zip=01;31:*.z=01;31:*.dz=01;31:*.gz=01;31:*.lrz=01;31:*.lz=01;31:*.lzo=01;31:*.xz=01;31:*.zst=01;31:*.tzst=01;31:*.bz2=01;31:*.bz=01;31:*.tbz=01;31:*.tbz2=01;31:*.tz=01;31:*.deb=01;31:*.rpm=01;31:*.jar=01;31:*.war=01;31:*.ear=01;31:*.sar=01;31:*.rar=01;31:*.alz=01;31:*.ace=01;31:*.zoo=01;31:*.cpio=01;31:*.7z=01;31:*.rz=01;31:*.cab=01;31:*.wim=01;31:*.swm=01;31:*.dwm=01;31:*.esd=01;31:*.jpg=01;35:*.jpeg=01;35:*.mjpg=01;35:*.mjpeg=01;35:*.gif=01;35:*.bmp=01;35:*.pbm=01;35:*.pgm=01;35:*.ppm=01;35:*.tga=01;35:*.xbm=01;35:*.xpm=01;35:*.tif=01;35:*.tiff=01;35:*.png=01;35:*.svg=01;35:*.svgz=01;35:*.mng=01;35:*.pcx=01;35:*.mov=01;35:*.mpg=01;35:*.mpeg=01;35:*.m2v=01;35:*.mkv=01;35:*.webm=01;35:*.webp=01;35:*.ogm=01;35:*.mp4=01;35:*.m4v=01;35:*.mp4v=01;35:*.vob=01;35:*.qt=01;35:*.nuv=01;35:*.wmv=01;35:*.asf=01;35:*.rm=01;35:*.rmvb=01;35:*.flc=01;35:*.avi=01;35:*.fli=01;35:*.flv=01;35:*.gl=01;35:*.dl=01;35:*.xcf=01;35:*.xwd=01;35:*.yuv=01;35:*.cgm=01;35:*.emf=01;35:*.ogv=01;35:*.ogx=01;35:*.aac=00;36:*.au=00;36:*.flac=00;36:*.m4a=00;36:*.mid=00;36:*.midi=00;36:*.mka=00;36:*.mp3=00;36:*.mpc=00;36:*.ogg=00;36:*.ra=00;36:*.wav=00;36:*.oga=00;36:*.opus=00;36:*.spx=00;36:*.xspf=00;36:
ECS_AGENT_URI=http://169.254.170.2/api/0ba05e109d2246bfa4213045b0b3b0c7-2990360344
TERM=xterm-256color
ECS_CONTAINER_METADATA_URI=http://169.254.170.2/v3/0ba05e109d2246bfa4213045b0b3b0c7-2990360344
SHLVL=1
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
_=/usr/bin/printenv
問題なく操作できました。なお、SSMセッションマネージャーのセッションログをS3バケットやCloudWatch Logsに出力するようにしている場合は権限不足で接続できないと思われます。コードを変更して適切なアクセス許可をしてあげましょう。
SessionId: botocore-session-1709692953-074d742931e439454 : Couldn't start the session because we are unable to validate encryption on Amazon S3 bucket. Error: AccessDenied: Access Denied
status code: 403, request id: M69254NJ2XKEX1KD, host id: aOYdS3DE1fH6rf8TOXvZpmSNmTBWx20+TAo+6n85VHoUAi2um0jDSwwWU9ESUxZzjwvexLbdVn0=
SSMセッションマネージャーのリモートホストのポートフォワーディングも試してみましょう。SSMセッションマネージャーのリモートホストのポートフォワーディングの紹介は以下記事をご覧ください。
Aurora PostgreSQLへのポートフォワーディングをしてあげます。
$ db_endopoint="db-cluster.cluster-cicjym7lykmq.us-east-1.rds.amazonaws.com"
$ aws ssm start-session \
--target "ecs:${cluster_name}_${task_id}_${runtime_id}" \
--document-name AWS-StartPortForwardingSessionToRemoteHost \
--parameters '{"host":["'${db_endopoint}'"],"portNumber":["5432"], "localPortNumber":["15432"]}'
Starting session with SessionId: botocore-session-1709724330-0eb982afb8a0e4f31
Port 15432 opened for sessionId botocore-session-1709724330-0eb982afb8a0e4f31.
Waiting for connections...
別セッションでポートフォワーディングしているポートに対して接続します。
# Autora PostgreSQLの認証情報取得
$ get_secrets_value=$(aws secretsmanager get-secret-value \
--secret-id AuroraSecret7ACECA7F-jZsiEVe2jrDs \
--region us-east-1 \
| jq -r .SecretString)
$ export PGHOST=localhost
$ export PGPORT=15432
$ export PGDATABASE=$(echo "${get_secrets_value}" | jq -r .dbname)
$ export PGUSER=$(echo "${get_secrets_value}" | jq -r .username)
$ export PGPASSWORD=$(echo "${get_secrets_value}" | jq -r .password)
$ psql
psql (16.2, server 15.5)
SSL connection (protocol: TLSv1.3, cipher: TLS_AES_256_GCM_SHA384, compression: off)
Type "help" for help.
testDB=> SELECT version();
version
-------------------------------------------------------------------------------------------------------------
PostgreSQL 15.5 on aarch64-unknown-linux-gnu, compiled by aarch64-unknown-linux-gnu-gcc (GCC) 9.5.0, 64-bit
(1 row)
testDB=> SELECT aurora_db_instance_identifier();
aurora_db_instance_identifier
-------------------------------
db-instance-writer
(1 row)
testDB=> SELECT * FROM aurora_global_db_instance_status();
server_id | session_id | aws_region | durable_lsn | highest_lsn_rcvd | feedback_epoch | feedback_xmin | oldest_read_view_lsn | visibility_lag_in_msec
--------------------+-------------------+------------+-------------+------------------+----------------+---------------+----------------------+------------------------
db-instance-writer | MASTER_SESSION_ID | us-east-1 | 179417812 | | | | |
(1 row)
testDB=> SELECT inet_client_addr();
inet_client_addr
------------------
10.1.16.211
(1 row)
問題なく接続でき、クエリを叩くこともできました。接続元のIPアドレスはECSタスクのIPアドレスになっています。
Egress Subnetにデプロイ × Pull through cache(初回)
続いて、Pull through cacheを試してみます。./lib/parameter/index.ts
は以下のとおりです。
export const ecsFargateBastionStackParams: EcsFargateBastionStackParams = {
env: {
account: process.env.CDK_DEFAULT_ACCOUNT,
region: process.env.CDK_DEFAULT_REGION,
},
property: {
vpcId: "vpc-0c923cc42e5fb2cbf",
ecsFargateParams: {
ecsFargateSubnetSelection: {
subnetType: cdk.aws_ec2.SubnetType.PRIVATE_WITH_EGRESS,
availabilityZones: ["us-east-1a"],
},
clusterName: "ecs-fargate-bastion",
ecrRepositoryPrefix: "ecr-public-pull-through",
repositoryName: "ecr-public-pull-through/ubuntu/ubuntu",
imagesTag: "22.04",
desiredCount: 1,
ecsServiceSecurityGroupIds: ["sg-05744485862b195ae"],
},
},
};
デプロイ後、ECRのプライベートリポジトリを確認すると、指定したコンテナイメージがpushされていました。
起動してきたECSタスクを確認すると、Pull through cacheによるものと思われるコンテナイメージを使っていました。
起動したECSタスクへのECS Exec、SSMセッションマネージャー、SSMセッションマネージャーのリモートホストのポートフォワーディングのいずれも問題なく行えました。参考までにECS Execのログは以下のとおりです。
Isolated Subnetにデプロイ × Pull through cache(2回目)
次に、Isolated Subnetにデプロイします。
続いて、Pull through cacheを試してみます。./lib/parameter/index.ts
は以下のとおりです。
export const ecsFargateBastionStackParams: EcsFargateBastionStackParams = {
env: {
account: process.env.CDK_DEFAULT_ACCOUNT,
region: process.env.CDK_DEFAULT_REGION,
},
property: {
vpcId: "vpc-0c923cc42e5fb2cbf",
vpcEndpointParams: {
vpcEndpointSubnetSelection: {
subnetType: cdk.aws_ec2.SubnetType.PRIVATE_ISOLATED,
availabilityZones: ["us-east-1a"],
},
shouldCreateEcrVpcEndpoint: true,
shouldCreateSsmVpcEndpoint: true,
shouldCreateLogsVpcEndpoint: true,
shouldCreateS3VpcEndpoint: true,
},
ecsFargateParams: {
ecsFargateSubnetSelection: {
subnetType: cdk.aws_ec2.SubnetType.PRIVATE_ISOLATED,
availabilityZones: ["us-east-1a"],
},
clusterName: "ecs-fargate-bastion",
ecrRepositoryPrefix: "ecr-public-pull-through",
repositoryName: "ecr-public-pull-through/ubuntu/ubuntu",
imagesTag: "22.04",
desiredCount: 1,
ecsServiceSecurityGroupIds: ["sg-05744485862b195ae"],
},
},
};
構成図にすると以下のとおりです。
デプロイ後、起動してきたECSタスクを確認すると、Pull through cacheによるものと思われるコンテナイメージを使っていました。
Isolated Subnetなのでamazon-ecs-exec-checkerでも叩いてみます。
$ ./check-ecs-exec.sh ecs-fargate-bastion be519c666386432289d355afb155162b
-------------------------------------------------------------
Prerequisites for check-ecs-exec.sh v0.7
-------------------------------------------------------------
jq | OK (/opt/homebrew/bin/jq)
AWS CLI | OK (/opt/homebrew/bin/aws)
-------------------------------------------------------------
Prerequisites for the AWS CLI to use ECS Exec
-------------------------------------------------------------
AWS CLI Version | OK (aws-cli/2.15.15 Python/3.11.7 Darwin/23.2.0 source/arm64 prompt/off)
Session Manager Plugin | OK (1.2.553.0)
-------------------------------------------------------------
Checks on ECS task and other resources
-------------------------------------------------------------
Region : us-east-1
Cluster: ecs-fargate-bastion
Task : be519c666386432289d355afb155162b
-------------------------------------------------------------
Cluster Configuration |
KMS Key : Not Configured
Audit Logging : OVERRIDE
S3 Bucket Name: Not Configured
CW Log Group : /ecs/ecs-fargate-bastion/ecr-public-pull-through/ubuntu/ubuntu/ecs-exec, Encryption Enabled: false
Can I ExecuteCommand? | arn:aws:iam::<AWSアカウントID>:role/<IAMロール名>
ecs:ExecuteCommand: allowed
ssm:StartSession denied?: allowed
Task Status | RUNNING
Launch Type | Fargate
Platform Version | 1.4.0
Exec Enabled for Task | OK
Container-Level Checks |
----------
Managed Agent Status
----------
1. RUNNING for "Container"
----------
Init Process Enabled (EcsFargateBastionStackEcsFargateTaskDefinition6FF60031:11)
----------
1. Enabled - "Container"
----------
Read-Only Root Filesystem (EcsFargateBastionStackEcsFargateTaskDefinition6FF60031:11)
----------
1. Disabled - "Container"
Task Role Permissions | arn:aws:iam::<AWSアカウントID>:role/EcsFargateBastionStack-EcsFargateTaskDefinitionTask-CirFF1nrXFna
ssmmessages:CreateControlChannel: allowed
ssmmessages:CreateDataChannel: allowed
ssmmessages:OpenControlChannel: allowed
ssmmessages:OpenDataChannel: allowed
-----
logs:DescribeLogGroups: allowed
logs:CreateLogStream: allowed
logs:DescribeLogStreams: allowed
logs:PutLogEvents: allowed
VPC Endpoints |
Found existing endpoints for vpc-0c923cc42e5fb2cbf:
- com.amazonaws.us-east-1.s3
- com.amazonaws.us-east-1.logs
- com.amazonaws.us-east-1.ecr.api
- com.amazonaws.us-east-1.ecr.dkr
- com.amazonaws.us-east-1.ssmmessages
- com.amazonaws.us-east-1.ssm
Environment Variables | (EcsFargateBastionStackEcsFargateTaskDefinition6FF60031:11)
1. container "Container"
- AWS_ACCESS_KEY: not defined
- AWS_ACCESS_KEY_ID: not defined
- AWS_SECRET_ACCESS_KEY: not defined
特に問題なさそうです。必要なVPCエンドポイントを正しく認識しています。
起動したECSタスクへのECS Exec、SSMセッションマネージャー、SSMセッションマネージャーのリモートホストのポートフォワーディングのいずれも問題なく行えました。参考までにECS Execのログは以下のとおりです。
Isolated Subnetにデプロイ × Pull through cache(初回)
次に、初めてPull through cacheを使用してイメージをpullする際にインターネットへの接続がない場合の挙動を確認してみます。
AWS公式ドキュメントには以下のように「初めてpullする場合はNAT Gatewayが必要」との記載があります。
初めてプルスルーキャッシュルールを使用してイメージをプルするとき、AWS PrivateLink を使って、インターフェイス VPC エンドポイントを使用するように Amazon ECR を設定した場合、NAT ゲートウェイを使用して、同じ VPC 内にパブリックサブネットを作成し、プルが機能するように、プライベートサブネットから NAT ゲートウェイへのすべてのアウトバウンドトラフィックをインターネットにルーティングする必要があります。その後のイメージプルでは、これは必要ありません。詳細については、Amazon Virtual Private Cloud ユーザーガイドの「シナリオ: プライベートサブネットからインターネットにアクセスする」を参照してください。
Amazon ECR インターフェイス VPC エンドポイント (AWS PrivateLink) - Amazon ECR
しかし、以下記事ではNAT Gatewayへのルートが存在しない場合でも初回のpullができたと紹介されています。
実際に私も試してみました。
Pull through cache ruleで使用するリポジトリプレフィックスを変更してみます。./lib/parameter/index.ts
は以下のとおりです。
export const ecsFargateBastionStackParams: EcsFargateBastionStackParams = {
env: {
account: process.env.CDK_DEFAULT_ACCOUNT,
region: process.env.CDK_DEFAULT_REGION,
},
property: {
vpcId: "vpc-0c923cc42e5fb2cbf",
vpcEndpointParams: {
vpcEndpointSubnetSelection: {
subnetType: cdk.aws_ec2.SubnetType.PRIVATE_ISOLATED,
availabilityZones: ["us-east-1a"],
},
shouldCreateEcrVpcEndpoint: true,
shouldCreateSsmVpcEndpoint: true,
shouldCreateLogsVpcEndpoint: true,
shouldCreateS3VpcEndpoint: true,
},
ecsFargateParams: {
ecsFargateSubnetSelection: {
subnetType: cdk.aws_ec2.SubnetType.PRIVATE_ISOLATED,
availabilityZones: ["us-east-1a"],
},
clusterName: "ecs-fargate-bastion",
ecrRepositoryPrefix: "ecr-public-pull-through2",
repositoryName: "ecr-public-pull-through2/ubuntu/ubuntu",
imagesTag: "22.04",
desiredCount: 1,
ecsServiceSecurityGroupIds: ["sg-05744485862b195ae"],
},
},
};
デプロイ後、起動してきたECSタスクを確認すると、変更後のPull through cache ruleで指定したリポジトリプレフィックスのコンテナイメージを使っていることがわかりました。
先述の記事で紹介しているとおり、NAT Gatewayへのルートが存在しない場合でも初回のpullができるのでしょうか。
今度はコンテナイメージを変更してみます。Ubuntu 22.04からbusyboxに変更します。./lib/parameter/index.ts
は以下のとおりです。
export const ecsFargateBastionStackParams: EcsFargateBastionStackParams = {
env: {
account: process.env.CDK_DEFAULT_ACCOUNT,
region: process.env.CDK_DEFAULT_REGION,
},
property: {
vpcId: "vpc-0c923cc42e5fb2cbf",
vpcEndpointParams: {
vpcEndpointSubnetSelection: {
subnetType: cdk.aws_ec2.SubnetType.PRIVATE_ISOLATED,
availabilityZones: ["us-east-1a"],
},
shouldCreateEcrVpcEndpoint: true,
shouldCreateSsmVpcEndpoint: true,
shouldCreateLogsVpcEndpoint: true,
shouldCreateS3VpcEndpoint: true,
},
ecsFargateParams: {
ecsFargateSubnetSelection: {
subnetType: cdk.aws_ec2.SubnetType.PRIVATE_ISOLATED,
availabilityZones: ["us-east-1a"],
},
clusterName: "ecs-fargate-bastion",
ecrRepositoryPrefix: "ecr-public-pull-through3",
repositoryName: "ecr-public-pull-through3/docker/library/busybox",
imagesTag: "stable-musl",
imagesTag: "22.04",
desiredCount: 1,
ecsServiceSecurityGroupIds: ["sg-05744485862b195ae"],
},
},
};
デプロイ後に起動してきたECSタスクを確認すると、確かにbusyboxのイメージを使用しています。
Pull through cacheで作成されたリポジトリにもイメージがpushされています。
この後もスタックを一から作り直して再チャレンジしましたが、結果は同じでした。NAT Gatewayへのルートが存在しない場合でも初回のpullができるようです。
ちなみに、コンテナイメージをUbuntu 22.04からbusyboxに変更すると、スタックを更新してからタスクの起動が完了するまでの時間が4分から1分と3分短くなりました。
また、タスクを停止させて、新しいタスクが実行中になるまでにかかった時間は30秒ほどでした(停止をしてから新しいタスクを起動し始めるまで15秒 /起動したタスクが実行中になるまで15秒)。このぐらいの速度であれば通常はタスクの数を0にしておいて、必要になったタイミングで1に変更するといった運用も耐えられそうです。
起動したECSタスクへのECS Exec、SSMセッションマネージャー、SSMセッションマネージャーのリモートホストのポートフォワーディングのいずれも問題なく行えました。参考までにECS Execのログは以下のとおりです。
NAT Gatewayがないサブネットにも簡単に踏み台を用意できる
AWS CDKでECS Fargate Bastionを一撃で作ってみました。
NAT Gatewayがないサブネットにも簡単に踏み台を用意できましたね。
なお、コンテナにECS ExecやSSMセッションマネージャーして、そこからコマンドを色々叩きたい方はコンテナイメージのビルドが必要です。ぜひカスタマイズしてみてください。
この記事が誰かの助けになれば幸いです。
以上、AWS事業本部 コンサルティング部の のんピ(@non____97)でした!